A Brief Discussion on Exception Handling and State Restoration for SaveChanges() in Entity Framework
This is likely the last note on Entity Framework for a while. I've been researching WSL lately, yet my notes keep ending up being about Entity Framework, making it seem like I'm at odds with it.
I originally intended to split this into two articles, but since the content is highly related and I'm feeling a bit lazy, I've combined them into one.
Entity Framework Exception Messages
There are three common exceptions in Entity Framework:
DbUpdateException: An exception thrown when an error occurs while saving to the database (e.g., violation of database constraints or other storage operation failures). This exception usually wraps lower-level exceptions, such as database connection errors or SQL execution errors.
DbUpdateConcurrencyException: An exception thrown when a concurrency issue occurs while saving to the database. This usually happens when
RowVersionorConcurrencyCheckattributes are set on an Entity type to implement concurrency control. When EF detects that the data in the database has been modified by another operation and the current operation's data version does not match, it throws this exception.DbEntityValidationException: An exception thrown when
SaveChanges()is called and Entity validation fails. This exception is typically used to catch data validation errors in Entities, such as property values not meeting Data Annotation requirements (e.g.,[Required],[MaxLength]). It has been removed in Entity Framework Core.
TIP
While handling Entity Framework error messages, I found that I couldn't locate DbEntityValidationException. After checking, I realized it had been removed, which was quite surprising. For the reasons behind its removal, you can refer to Will 保哥's article: "EF Core no longer performs extra validation on entity models during SaveChanges()". Although I believe Model Binding validation and Entity validation should be viewed separately, upon closer inspection, the benefit of Entity validation is that it checks data before writing to the database, which can reduce some overhead. However, in practice, Model Binding and Service Layer validation can block most scenarios, so the need for Entity validation is indeed rare.
To be honest, I am always troubled by EF Exception messages. For example, you might see the following:
- EF Core's
DbUpdateExceptionmessage:
An error occurred while saving the entity changes. See the inner exception for details.
- EF's
DbEntityValidationExceptionmessage:
One or more entities failed validation. For more details, see the 'EntityValidationErrors' property.
Only heaven knows the actual cause, which makes it necessary to handle these exceptions specifically. How to handle Entity Exceptions mainly depends on whether the frontend will see the exception error message when an exception (referring to all exceptions here) occurs:
- When the system returns the raw exception message directly to the frontend: To avoid exposing too much detail to the frontend, you should extract the complete error message from
InnerExceptionorEntityValidationErrorsand record it in the log when writing the Exception Log. This ensures that the log contains detailed error information while allowing the frontend to see only the generic message. - When the frontend cannot see the exception message: In this case, you can override the
SaveChanges()method in the DbContext to catch the Entity Exception and re-throw an exception of the same type, setting theMessageto the complete error message. This way, the Exception Log doesn't require extra processing, making error handling and responsibility division clearer.
Restoring Entity State When SaveChanges() Fails
During data processing, database data validation is usually relied upon to ensure that abnormal data is not written, or default values are used to avoid errors caused by missing data. However, in my opinion, one should not over-rely on database checks or default values, as this can lead to unexpected issues. This section stems from a mistake I made many years ago.
At the time, the scheduler program used ADO.NET for data writing. To save time, the developer did not check for duplicate data before writing, relying instead on the primary key to block duplicates. When I rewrote this code into Entity Framework, I continued this approach. As mentioned in "A Brief Discussion on Synchronizing Navigation Properties and Foreign Keys in Entity Framework", when SaveChanges() fails, the Entity state is preserved. This means that if the SaveChanges() for the first piece of data fails, when you attempt to add a second piece of data and call SaveChanges() again, the generated SQL statement will include the first piece of data. Consequently, once a failure occurs, all subsequent changes will also fail.
Of course, preserving the Entity state after a SaveChanges() failure can be helpful in some cases, such as failures due to network instability, allowing for retries of SaveChanges(). I have seen projects where SaveChanges() is automatically retried up to three times until it succeeds or is abandoned. However, if you do not want failed changes to be preserved in specific scenarios, you can consider overriding SaveChanges() and, upon catching a DbUpdateException, restoring the Entity state to ignore that specific transaction.
TIP
There are different views in the industry regarding whether default values should be set, mainly divided into two:
Supporting default values: Setting default values helps avoid errors when data is missing or not saved, which can reduce the probability of application issues and ensure data integrity.
Opposing default values: Supporting setting columns to
NOT NULLwithout default values ensures that if data is not saved correctly, the program will report an error immediately, helping developers discover and fix potential issues early and avoiding the risk of hidden errors.
The design philosophies of the two approaches differ, and neither is necessarily right or wrong, but if the team has no specific requirements, I personally prefer the second approach.
WARNING
Note that the method of restoring Entity State after SaveChanges() fails is only applicable to Entity structures that do not contain foreign keys. The specific reasons will be explained later.
Code Implementation
I'll take a shortcut here and combine the code for both sections. Since EF Core has removed DbEntityValidationException, I will not handle that part. The handling of Entity State is shown in the table below:
| State | Description | Handling Method |
|---|---|---|
| Detached | Not tracked. | No action needed. |
| Unchanged | Retrieved from the database and not modified. | No action needed. |
| Deleted | Retrieved from the database and removed using Remove. | Change State to Unchanged. |
| Modified | Retrieved from the database and properties have been modified. | Change State to Unchanged and use entry.CurrentValues.SetValues(entry.OriginalValues) to restore data. |
| Added | Data existing only locally. | Change State to Detached. |
public partial class TestEFContext {
public override int SaveChanges() {
return SaveChanges(true);
}
public override int SaveChanges(bool acceptAllChangesOnSuccess) {
try {
return base.SaveChanges(acceptAllChangesOnSuccess);
} catch (DbUpdateException ex) {
throw ResetEntityStateAndFixMessage(ex);
}
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) {
return SaveChangesAsync(true, cancellationToken);
}
public override async Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default
) {
try {
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
} catch (DbUpdateException ex) {
throw ResetEntityStateAndFixMessage(ex);
}
}
private DbUpdateException ResetEntityStateAndFixMessage(DbUpdateException ex) {
ResetEntityStates(ChangeTracker.Entries());
return new DbUpdateException(ex.InnerException.Message, ex);
}
private static void ResetEntityStates(IEnumerable<EntityEntry> entries) {
foreach (EntityEntry entry in entries) {
ResetEntityState(entry);
}
}
private static void ResetEntityState(EntityEntry entry) {
switch (entry.State) {
case EntityState.Added:
entry.State = EntityState.Detached;
break;
case EntityState.Modified:
entry.CurrentValues.SetValues(entry.OriginalValues);
entry.State = EntityState.Unchanged;
break;
case EntityState.Deleted:
// Under normal circumstances, the deleted EntityState should be set to Unchanged.
// However, in practice, whether set to Unchanged or Detached, when an Entity removed via Remove() cannot be added back to the navigation property,
// setting EntityState to Unchanged might cause the navigation property to still lack the previously removed Entity when re-querying data.
// Therefore, for related EntityEntry.State, set to EntityState.Detached.
// This way, at least the navigation property can be normal when re-querying data.
entry.State = entry.Entity is Dictionary<string, object>
? EntityState.Detached
: EntityState.Unchanged;
break;
}
}
}Test Results
When adding an Entity via a navigation property, that Entity is also added to tracking. Therefore, the problem is more likely to occur when removing associations. Thus, use the following test code to test the scenario of removing associations:
The Entity structure is as follows:
modelBuilder.Entity<Table1>(entity => {
entity.ToTable("Table1");
entity.Property(e => e.Id).ValueGeneratedNever();
entity.HasMany(d => d.Table2s)
.WithMany(p => p.Table1s)
.UsingEntity<Dictionary<string, object>>(
"TableRef",
l => l.HasOne<Table2>().WithMany().HasForeignKey("Table2Id").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_TableRef_Table2"),
r => r.HasOne<Table1>().WithMany().HasForeignKey("Table1Id").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_TableRef_Table1"),
j => {
j.HasKey("Table1Id", "Table2Id").HasName("PK_Table_3");
j.ToTable("TableRef");
});
});
modelBuilder.Entity<Table2>(entity => {
entity.ToTable("Table2");
entity.Property(e => e.Id).ValueGeneratedNever();
});
public partial class Table1 {
public Table1() {
Table2s = new HashSet<Table2>();
}
public long Id { get; set; }
public virtual ICollection<Table2> Table2s { get; set; }
}
public partial class Table2 {
public Table2() {
Table1s = new HashSet<Table1>();
}
public long Id { get; set; }
public virtual ICollection<Table1> Table1s { get; set; }
}Existing database data: Table1
| Id |
|---|
| 1 |
| 2 |
| 3 |
Table2
| Id |
|---|
| 1 |
| 2 |
TableRef
| Table1Id | Table2Id |
|---|---|
| 1 | 1 |
| 2 | 2 |
Use the following code for testing:
using TestEFContext dbContext = new(dbContextOptions);
// Retrieve records in Table1 and Table2, including related navigation properties
Table1 table11 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 1);
Table1 table12 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 2);
Table2 table21 = dbContext.Table2s.Include(x => x.Table1s).Single(x => x.Id == 1);
Table2 table22 = dbContext.Table2s.Include(x => x.Table1s).Single(x => x.Id == 2);
PrintLog();
table11.Table2s.Remove(table21);
PrintLog();
table12.Table2s.Add(table21);
PrintLog();
try {
// Intentionally trigger a primary key conflict error by attempting to insert an existing Table1 record
dbContext.Add(new Table1 {
Id = 3
});
dbContext.SaveChanges();
} catch (Exception) {
}
PrintLog();
table11 = dbContext.Table1s.Include(x => x.Table2s).Single(x => x.Id == 1);
Console.WriteLine($"table11's Table2s association count: {table11.Table2s.Count}");
void PrintLog() {
foreach (EntityEntry entry in dbContext.ChangeTracker.Entries()) {
Console.WriteLine(entry.ToString());
}
// Output the association count between each Table1 and Table2
Console.WriteLine($"table11's Table2s association count: {table11.Table2s.Count}");
Console.WriteLine($"table12's Table2s association count: {table12.Table2s.Count}");
Console.WriteLine($"table21's Table1s association count: {table21.Table1s.Count}");
Console.WriteLine($"table22's Table1s association count: {table22.Table1s.Count}");
Console.WriteLine();
}The execution results are as follows:
Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Unchanged FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 1
table12's Table2s association count: 1
table21's Table1s association count: 1
table22's Table1s association count: 1
Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Deleted FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 0
table12's Table2s association count: 1
table21's Table1s association count: 0
table22's Table1s association count: 1
Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 1} Added FK {Table1Id: 2} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Deleted FK {Table1Id: 1} FK {Table2Id: 1}
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 0
table12's Table2s association count: 2
table21's Table1s association count: 1
table22's Table1s association count: 1
Exception error
Table1 {Id: 1} Unchanged
Table2 {Id: 1} Unchanged
Table1 {Id: 2} Unchanged
Table2 {Id: 2} Unchanged
TableRef (Dictionary<string, object>) {Table1Id: 2, Table2Id: 2} Unchanged FK {Table1Id: 2} FK {Table2Id: 2}
table11's Table2s association count: 0
table12's Table2s association count: 1
table21's Table1s association count: 0
table22's Table1s association count: 1From the results, adding or removing associations via navigation properties does not affect the Entity State, but an EntityEntry for the association is actually generated. However, when restoring the EntityEntry.State of the association, only the Add() change is restored, and the Remove() part is not handled.
Regarding the handling of the EntityState.Deleted scenario on line 50 of TestEFContext, the differences in results based on how the TableRef Entity State is handled are explained below:
TableRef count:
EntityState.Unchanged: There will be two records in TableRef, which is the same as the situation beforeRemove(), and this result is correct.EntityState.Detached: There is only one record in TableRef, missingTableRef (Dictionary<string, object>) {Table1Id: 1, Table2Id: 1} Unchanged FK {Table1Id: 1} FK {Table2Id: 1}.
table11.Table2s.Count: Both are 0.When re-retrieving data for
Table1.Idequal to1from the database:EntityState.Unchanged:Table2s.Countis still 0. It is speculated that this is related to the DbContext caching mechanism mentioned in "EF Core DbContext Cache Feature Experiment" and "How Query Works". Although data is queried from the database, because the DbContext already contains the data and it is already tracked, the Entity inside the DbContext is returned directly. Honestly, this looks like a bug to me...EntityState.Detached:Table2s.Countwill be 1, and the navigation property successfully re-retrieves data from the database.
Although setting it to EntityState.Detached yields slightly better results in usage, there are actually problems with both, so it is not recommended to use Entity state restoration when foreign keys are present.
WARNING
Using DbSet.Add() to add an Entity with the same PK as already queried data will throw an InvalidOperationException. Since the exception is thrown during Add(), not SaveChanges(), it will not be caught by the existing error handling mechanism.
Change Log
- 2024-08-17 Initial document creation.